Skip to content

feat: controller lease acquisition metrics (JEP-0013 Phase 2) - #932

Open
RoddieKieley wants to merge 3 commits into
jumpstarter-dev:mainfrom
RoddieKieley:jep-0013-phase2-controller-metrics
Open

feat: controller lease acquisition metrics (JEP-0013 Phase 2)#932
RoddieKieley wants to merge 3 commits into
jumpstarter-dev:mainfrom
RoddieKieley:jep-0013-phase2-controller-metrics

Conversation

@RoddieKieley

Copy link
Copy Markdown
  • Add jumpstarter_lease_acquisitions_total{result} on the Jumpstarter controller /metrics endpoint with Prometheus exemplars (default keys client, lease_id).
  • Keep operator-managed Controller -metrics-bind-address=:8080; no Router metrics flags in this PR.
  • Tests cover series registration, acquire success/failure increments, exemplars, and Controller metrics bind assertions (JEP-0013 Phase 2, controller slice).

- Add `jumpstarter_lease_acquisitions_total{result}` on the Jumpstarter controller `/metrics` endpoint with Prometheus exemplars (default keys `client`, `lease_id`).
- Keep operator-managed Controller `-metrics-bind-address=:8080`; no Router metrics flags in this PR.
- Tests cover series registration, acquire success/failure increments, exemplars, and Controller metrics bind assertions (JEP-0013 Phase 2, controller slice).
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b0913830-7385-4b97-836b-d49b11d16269

📥 Commits

Reviewing files that changed from the base of the PR and between 2fac66d and b1b44f2.

📒 Files selected for processing (4)
  • controller/internal/controller/lease_acquisition_metrics_test.go
  • controller/internal/controller/lease_controller.go
  • controller/internal/metrics/lease.go
  • controller/internal/metrics/lease_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • controller/internal/metrics/lease.go

📝 Walkthrough

Walkthrough

The controller adds Prometheus lease-acquisition metrics, records success and failure transitions during reconciliation, registers default metrics at startup, and tests metric behavior and deployment metrics binding.

Changes

Lease acquisition metrics

Layer / File(s) Summary
Metrics collector and validation
controller/internal/metrics/lease.go, controller/internal/metrics/lease_test.go
Defines the lease-acquisition counter, allowlisted exemplars, registration helpers, UTF-8 and budget handling, and metric output tests.
Lease reconciliation outcome recording
controller/internal/controller/lease_controller.go, controller/internal/controller/lease_acquisition_metrics_test.go
Classifies new exporter assignments and unsatisfiable transitions. It records outcomes after status persistence and tests one-time recording behavior.
Controller startup and deployment binding
controller/cmd/main.go, controller/deploy/operator/internal/controller/jumpstarter/controller_metrics_bind_test.go
Registers default metrics during startup and verifies the deployment exposes metrics on port 8080.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant LeaseReconciler
  participant LeaseMetrics
  participant PrometheusRegistry
  LeaseReconciler->>LeaseMetrics: RecordAcquisition(result, lease and client exemplars)
  LeaseMetrics->>PrometheusRegistry: Increment labeled lease counter
  LeaseMetrics->>PrometheusRegistry: Attach constrained exemplar labels
Loading

Possibly related PRs

Suggested reviewers: bennyz, mangelajo

Poem

A rabbit counts each lease with care,
Success and failure settle there.
Exemplars fit the metric stream,
Port 8080 supports the scheme.
Startup registers the dream.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the addition of controller lease acquisition metrics and includes the relevant implementation phase.
Description check ✅ Passed The description directly summarizes the new lease acquisition metric, exemplars, metrics binding, and related tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@controller/deploy/operator/internal/controller/jumpstarter/controller_metrics_bind_test.go`:
- Around line 27-77: The stdlib-only TestControllerDeploymentMetricsBind
violates the operator test harness requirement. Move or adapt this assertion
into the existing envtest-based test setup for the Jumpstarter controller,
preserving validation of the metrics argument and port, then verify with the
requested controller tests, type checks, lint, and full test commands.

In `@controller/internal/metrics/lease_test.go`:
- Around line 136-151: The TestOpenMetricsTextContainsSeries serialization flow
currently uses expfmt.MetricFamilyToText, which produces Prometheus text.
Replace it with the OpenMetrics formatter or encoder, then assert
OpenMetrics-specific output including exemplar labels and the required
OpenMetrics end-of-stream terminator while preserving the existing metric and
result-label assertions.

In `@controller/internal/metrics/lease.go`:
- Around line 81-83: Update the exemplar path in the metric handling code around
AddWithExemplar to validate or constrain the client and lease_id labels before
calling it, dropping the exemplar when either value is invalid rather than
allowing a panic. Preserve the existing metric increment behavior and only omit
the exemplar for invalid resource-derived values.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7e89286d-35c8-4332-b83e-c0e79c97a350

📥 Commits

Reviewing files that changed from the base of the PR and between 7fb0364 and d15976d.

📒 Files selected for processing (5)
  • controller/cmd/main.go
  • controller/deploy/operator/internal/controller/jumpstarter/controller_metrics_bind_test.go
  • controller/internal/controller/lease_controller.go
  • controller/internal/metrics/lease.go
  • controller/internal/metrics/lease_test.go

Comment on lines +136 to +151
var b strings.Builder
families, err := reg.Gather()
if err != nil {
t.Fatalf("gather: %v", err)
}
for _, f := range families {
if _, err := expfmt.MetricFamilyToText(&b, f); err != nil {
t.Fatalf("encode: %v", err)
}
}
out := b.String()
if !strings.Contains(out, LeaseAcquisitionsTotal) {
t.Fatalf("OpenMetrics text missing %s:\n%s", LeaseAcquisitionsTotal, out)
}
if !strings.Contains(out, `result="success"`) {
t.Fatalf("OpenMetrics text missing result label:\n%s", out)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate files =="
fd -a 'lease_test\.go|metrics.*go|prometheus|go.mod' . | sed 's#^\./##' | head -80

echo "== target file excerpt =="
sed -n '1,220p' controller/internal/metrics/lease_test.go

echo "== module prometheus deps =="
if [ -f go.mod ]; then cat go.mod; fi

echo "== imports/usages for expfmt/OpenMetrics in metrics =="
rg -n "MetricFamilyToText|MetricFamilyToTextToGo|MetricFamilyToTextToOpenMetrics|OpenMetrics|model\.Exemplar|exemplar|prometheus_client|client_golang" -S .

Repository: jumpstarter-dev/jumpstarter

Length of output: 21512


🌐 Web query:

prometheus/client_golang v1.22.0 MetricFamilyToText OpenMetrics

💡 Result:

In prometheus/client_golang v1.22.0, the function MetricFamilyToText is located in the prometheus/common/expfmt package [1][2]. It is specifically designed for the Prometheus text format and does not generate OpenMetrics output [3][4]. To handle OpenMetrics encoding, the library uses the expfmt.NewEncoder interface [4]. When using expfmt.NewEncoder with the TypeOpenMetrics format, the library calls MetricFamilyToOpenMetrics instead of MetricFamilyToText [4]. Additionally, while v1.22.0 supports a subset of OpenMetrics (such as exemplars) via opt-in configurations in promhttp.HandlerOpts, these features are considered experimental [5][6]. There is also a specific option, promhttp.HandlerOpts.EnableOpenMetricsTextCreatedSamples, which allows the creation of OpenMetrics _created lines, though this is not recommended due to significant cardinality increases [5][7].

Citations:


🌐 Web query:

prometheus/client_golang MetricFamilyToTextToOpenMetrics v1.22.0

💡 Result:

In the prometheus/client_golang library, functions related to metric parsing and encoding, such as MetricFamilyToText, are not located within the client_golang module itself [1][2]. Instead, these utilities are contained within the prometheus/common/expfmt package [1][3]. While prometheus/client_golang version 1.22.0 was released on April 7, 2025 [4][5], it does not introduce a specific function named MetricFamilyToTextToOpenMetrics. The primary functions for handling text-based exchange formats in the Prometheus ecosystem are typically found in the expfmt package, which includes: 1. MetricFamilyToText: Converts a MetricFamily protobuf message into the standard Prometheus text format [3]. 2. TextToMetricFamilies: Parses the Prometheus text format into MetricFamily protobuf messages [3][6]. Users seeking to interact with the OpenMetrics format or manipulate metric families generally utilize the tools provided by prometheus/common/expfmt [1][3]. Note that the client_golang library itself focuses on instrumentation and API client functionality, while common serialization and formatting logic is abstracted into the prometheus/common module [1][2]. If you are encountering a reference to a function named MetricFamilyToTextToOpenMetrics, it is likely a custom implementation or a misidentification of existing encoder/decoder functions within the expfmt package.

Citations:


Use an actual OpenMetrics encoder.

MetricFamilyToText emits the Prometheus text format, not OpenMetrics, so TestOpenMetricsTextContainsSeries cannot catch OpenMetrics serialization bugs. Encode the gathered families with the OpenMetrics formatter/encoder and assert the OpenMetrics-specific parts, including exemplar labels and the OpenMetrics terminator.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/internal/metrics/lease_test.go` around lines 136 - 151, The
TestOpenMetricsTextContainsSeries serialization flow currently uses
expfmt.MetricFamilyToText, which produces Prometheus text. Replace it with the
OpenMetrics formatter or encoder, then assert OpenMetrics-specific output
including exemplar labels and the required OpenMetrics end-of-stream terminator
while preserving the existing metric and result-label assertions.

Comment thread controller/internal/metrics/lease.go
Comment on lines +42 to +52
func NewLeaseMetrics() *LeaseMetrics {
return &LeaseMetrics{
acquisitions: prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: LeaseAcquisitionsTotal,
Help: "Lease acquire attempts on the Jumpstarter controller.",
},
[]string{"result"},
),
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Counter time series not pre-initialized with zero values. So, before any lease acquisition, scraping /metrics produces TYPE/HELP metadata but no data lines.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you need to pre-initialize to 0 ? I assumed that was the default in go.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Zero values in go - " Variables declared without an explicit initial value are given their zero value. "

@raballew raballew Aug 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://prometheus.io/docs/practices/instrumentation/#avoid-missing-metrics - its lazily initialized hence if you do do not prepopulate no graph will show.

Comment on lines +411 to +419
func setUnsatisfiableAndRecord(lease *jumpstarterdevv1alpha1.Lease, reason, messageFormat string, a ...any) {
already := meta.IsStatusConditionTrue(
lease.Status.Conditions,
string(jumpstarterdevv1alpha1.LeaseConditionTypeUnsatisfiable),
)
lease.SetStatusUnsatisfiable(reason, messageFormat, a...)
if !already {
recordLeaseAcquisition(lease, jmpmetrics.ResultFailure)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some test for the check that prevents double-counting on re-reconciliation would be nice.

}

// Default is the process-wide lease metrics instance used by the controller.
var Default = NewLeaseMetrics()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All controller tests share the global counter, breaking test isolation.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't those tests run in serial?, is it worth complicating the code in favor of testability? could be, just sharing the question here, I am not familiar with the prometheus metrics framework to be able to figure out if it'd get too complex, or if it really won't.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Evaluated and deferred as not required right now, although may be in the future.

H4. Global Default breaks test isolation
lease.go (var Default)
Claim: All controller tests share one process-wide counter.

Verdict: Soft accept — improve if cheap; don’t over-engineer

Metrics package tests already use NewLeaseMetrics() + private registries. Pain appears only if controller tests assert Default values (or run in parallel against it).

mangelajo’s question is fair: serial Ginkgo reduces flake risk; full DI may not be worth it for Phase 2.

Recommended change: Prefer injecting *LeaseMetrics on LeaseReconciler (defaulting to Default in production). Avoid a large framework change. If injection is deferred, at least don’t assert absolute global counter values in controller tests.

lease.Status.ExporterRef = &corev1.LocalObjectReference{
Name: selected.Exporter.Name,
}
recordLeaseAcquisition(lease, jmpmetrics.ResultSuccess)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The success metric is recorded here before status is persisted to the API server.

func recordLeaseAcquisition(lease *jumpstarterdevv1alpha1.Lease, result string) {
exemplars := map[string]string{}
if lease != nil {
exemplars["lease_id"] = lease.Name

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from the pov of exemplars and useful data, each lease_id is different, so it would not provide much statistical value.

What do you think about recording the assigned exporter name instead?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having only really run this in development in my own homelab I requested an evaluation of the implications of having lease_id both with and without and additional exporter as the initial take was 'The JEP has a specific design and the lease_id should not be removed'. The results sound reasonable, but as one of the authors of the JEP-0013 you might have a better idea than me if it simply "sounds reasonable" or is actually reasonable:

H5 in detail: lease_id vs exporter name on controller exemplars

mangelajo’s point is right for aggregation: each lease_id is unique, so it does not help “which hardware fails often?” style rates. Exemplars are not for that. JEP-0013 puts unbounded IDs in exemplars for drill-down, and keeps bounded identity like exporter on Prom/Loki labels where series exist for that purpose.

This PR’s series is only:

jumpstarter_lease_acquisitions_total{result="success|failure"}

with default exemplars client + lease_id. There is no exporter label on this counter.


1. With only client + lease_id (today / JEP defaults)

Operator flow when a dashboard shows a spike in failures (or a success blip):

  1. Grafana shows an exemplar dot on jumpstarter_lease_acquisitions_total{result="failure"} (last-seen exemplar per series).
  2. Click → see e.g. {client="ci-bot", lease_id="lease-abc"}.
  3. Use lease_id as the join key into Loki, e.g.
    {component="controller"} | json | lease_id="lease-abc"
    (and the same filter on exporter/cli streams once Phase 1/3 correlation fields are on the lines).
  4. From those log lines you recover the rest: assigned exporter (if any), selector, unsatisfiable reason, spec.context, etc.

So without an exporter exemplar key, lease_id is the primary bridge from “this metric sample” → “the one lease’s logs / CR”. client answers “which client,” not “which DUT.”

Limits of this path:

  • You need a working Loki (or API) lookup; the exemplar panel alone does not name the exporter.
  • Failure acquires often have no exporter assigned (ExporterNotFound, NoAccess, …), so an exporter key would be empty anyway.
  • Last-exemplar-wins: on a busy result="failure" series you only see the most recent lease, not a histogram of exporters (that still comes from logs or from exporter-labeled metrics elsewhere).

2. If exporter is also on the exemplar (in addition to lease_id)

On success (and only when Status.ExporterRef is set), the exemplar might look like:

{client="ci-bot", lease_id="lease-abc", exporter="sidekick-t450s"}

Operator flow:

  1. Same spike / click on the exemplar.
  2. Immediately see which exporter was assigned — useful for “was this the flaky board?” without opening Loki first.
  3. Still use lease_id for precise correlation: one exporter has many leases over time; lease_id picks the exact run and joins to that lease’s log cluster / spec.context.
  4. Optionally pivot: Loki by exporter="sidekick-t450s" for hardware trends, or by lease_id for one CI run.

What this does not replace:

  • Rates per exporter still belong on metrics that already have an exporter label (e.g. planned jumpstarter_operations_total{exporter=...}), not on unique exemplar values.
  • Replacing lease_id with exporter alone would weaken the join: many samples share one exporter name; you lose one-click identity of the lease that caused this observation.

Budget note: OpenMetrics caps exemplars at 128 runes. Adding exporter plus key name costs ~15–40+ characters and can force truncation of lease_id/client (you already log when that happens).


Side-by-side

Goal lease_id only lease_id + exporter
Join metric sample → one lease’s logs Direct Direct (same)
See DUT name in the exemplar popup Indirect (via Loki/API) Direct on success
Failure with no assignment Fine (lease_id still set) exporter omitted/empty
“Which exporter fails often?” Wrong tool (use labeled series / LogQL) Still wrong tool for rates; only a hint on last sample
Cardinality of the Prom series Unchanged Unchanged

Verdict for #932

Keep JEP defaults: client + lease_id. Do not replace lease_id with exporter.

Optional later (or a short PR reply to mangelajo): on success only, also attach exporter when Status.ExporterRef is set, still under the allowlist / 128-rune budget — additive drill-down, not a substitute for lease_id.

That matches the JEP table: lease_id is exemplar-for-drill-down; exporter is primarily a bounded Prom/Loki label on exporter-scoped metrics, not the lease-acquire join key.


Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kirkbrauer @raballew As others who reviewed the original JEP-0013 PR #631 will likely be afk for a few days at least maybe you have an educated opinion to chime in with?

Comment on lines +42 to +52
func NewLeaseMetrics() *LeaseMetrics {
return &LeaseMetrics{
acquisitions: prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: LeaseAcquisitionsTotal,
Help: "Lease acquire attempts on the Jumpstarter controller.",
},
[]string{"result"},
),
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you need to pre-initialize to 0 ? I assumed that was the default in go.

}

// Default is the process-wide lease metrics instance used by the controller.
var Default = NewLeaseMetrics()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't those tests run in serial?, is it worth complicating the code in favor of testability? could be, just sharing the question here, I am not familiar with the prometheus metrics framework to be able to figure out if it'd get too complex, or if it really won't.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants